import { listEvents } from "@autoscholar/core"; import { redact } from "@autoscholar/observability"; import { z } from "zod"; import { handler, json, parseQuery } from "@/lib/server/api"; import { db } from "@/lib/server/db"; import { requireAdmin } from "@/lib/server/session"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** * Reconnectable event stream. `?after=` (or `Last-Event-ID`) resumes; `?stream=1` returns Server-Sent Events, * otherwise a JSON page. Events are redacted before leaving the server. */ export const GET = handler(async (req, { params }) => { await requireAdmin(req); const { id } = (await params) as { id: string }; const q = parseQuery( req.url, z.object({ after: z.coerce.number().int().min(0).default(0), stream: z.string().optional(), limit: z.coerce.number().int().min(1).max(1000).default(500), }), ); const lastId = Number(req.headers.get("last-event-id") ?? 0); let after = Math.max(q.after, Number.isFinite(lastId) ? lastId : 0); if (!q.stream) { const events = await listEvents(db(), id, after, q.limit); return json({ events: redact(events), lastSeq: events.at(-1)?.seq ?? after }); } const encoder = new TextEncoder(); let closed = false; const stream = new ReadableStream({ async start(controller) { const send = (data: unknown, seq?: number, event = "run.event") => controller.enqueue( encoder.encode( `${seq !== undefined ? `id: ${seq}\n` : ""}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`, ), ); send({ connected: true, after }, undefined, "hello"); const started = Date.now(); while (!closed && Date.now() - started < 10 * 60_000) { try { const events = await listEvents(db(), id, after, 200); for (const ev of events) { send(redact(ev), ev.seq); after = ev.seq; } if (events.length === 0) controller.enqueue(encoder.encode(": keepalive\n\n")); } catch (e) { send({ error: (e as Error).message }, undefined, "error"); } await new Promise((r) => setTimeout(r, 1500)); } controller.close(); }, cancel() { closed = true; }, }); return new Response(stream, { headers: { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-store, no-transform", connection: "keep-alive", "x-accel-buffering": "no", }, }); });